<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd">
<!-- This page is only XHTML 1.0 Transitional because target is being used in a links -->
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>YaCy '#[clientname]#': LLM Selection</title>
    #%env/templates/metas.template%#
  </head>
  <body id="IndexControl" data-llm-service="#[llm_service]#" data-llm-hoststub="#[llm_hoststub]#" data-llm-apikey="#[llm_apikey]#">
    #%env/templates/header.template%#
    #%env/templates/submenuAI.template%#
    <script>
      let availableModels = [];

      const downloadActivities = new Map();
      let activeDownloadCount = 0;
      const beforeUnloadHandler = event => {
        event.preventDefault();
        event.returnValue = "Model downloads are still running. Please wait until they finish.";
      };
      // Localization-friendly test strings and hints (translate/tune as needed)
      const TEST_STRINGS = {
        toolingEndpointPath: "/v1/chat/completions", // Endpoint used to probe tooling capability on OpenAI-compatible APIs
        toolingSystemMessage: "You are a home assistant.", // System prompt for tooling capability test
        toolingUserMessage: "Switch on the light", // User prompt for tooling capability test
        visionSystemMessage: "you read out images", // System prompt for vision capability test
        visionUserMessage: "what is in the image?", // User prompt for vision capability test
        visionExpectedText: "42", // Expected mention in LLM response when reading the test image
        visionTestImagePath: "env/grafics/llmtest.png" // Image used for the vision capability test
      };

      const PRODUCTION_MODEL_TOTAL_COLUMNS = 15;
      const PRODUCTION_MODEL_MODEL_COLUMN_INDEX = 1;
      const PRODUCTION_MODEL_USAGE_COLUMN_START = 5;
      const PRODUCTION_MODEL_USAGE_COLUMN_END = 11; // including
      const PRODUCTION_MODEL_FEATURE_COLUMN_START = 12;
      const PRODUCTION_MODEL_FEATURE_COLUMN_END = 13; // including
      const PRODUCTION_MODEL_ACTION_COLUMN_INDEX = PRODUCTION_MODEL_TOTAL_COLUMNS - 1;
      const PRODUCTION_MODEL_TOOLING_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START;
      const PRODUCTION_MODEL_VISION_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 1;
      const PRODUCTION_MODEL_COLUMN_NAMES = [
        "service",
        "model",
        "hoststub",
        "api_key",
        "max_tokens",
        
        "search",
        "chat",
        "translation",
        "classification",
        "query",
        "qapairs",
        "tldr",

        "tooling",
        "vision"
      ];
      const PRODUCTION_MODEL_SUBMIT_URL = "LLMSelection_p.html";
      const TOOLING_EXPECTED_FUNCTION_NAME = "lightswitch";
      let cachedVisionTestImageBase64 = null;
      let cachedVisionTestImagePromise = null;

      const RECOMMENDED_MODELS = [
        ["smollm2:360m-instruct-q4_K_M",                                                         "0.001", "0.5GB", "english-only minimalistic model for small devices", "Huggingface", "apache-2.0"],
        ["hf.co/mradermacher/EuroLLM-1.7B-Instruct-GGUF:Q4_K_M",                                 "0.09", "1.5GB", "European Union - funded model, multilingual", "Various European Universities", "apache-2.0"],
        ["llama3.2:1b-instruct-q4_K_M",                                                          "0.18", "1.5GB", "A good 1B model", "Meta", "llama3.2"],
        ["llama3.2:3b-instruct-q4_K_M",                                                          "0.66", "3GB", "A good 3B model", "Meta", "llama3.2"],
        ["qwen3-vl:2b-instruct-q4_K_M",                                                          "",     "3GB", "A very small vision-model, can understand what is sees in images"],
        ["qwen3:4b-instruct-2507-q4_K_M",                                                        "7.70", "3GB", "Exceptional good 4B model", "Alibaba", "apache-2.0"],
        ["hf.co/mradermacher/Josiefied-Qwen3-4B-Instruct-2507-abliterated-v1-GGUF:Q4_K_M",       "4.41", "3GB", "Uncensored version of qwen3:4b", "huggingface.co/Goekdeniz-Guelmez", "apache-2.0"],
        ["hf.co/mradermacher/medgemma-4b-it-GGUF:Q4_K_M",                                        "0.84", "4GB", "Medical Knowledge and Vision", "Google", "health-ai-developer-foundations"],
        ["hf.co/mradermacher/occiglot-7b-eu5-instruct-GGUF:Q4_K_M",                              "0.19", "5GB", "Support for top-5 EU languages (English, Spanish, French, German, and Italian)", "occiglot.eu", "apache-2.0"],
        ["hf.co/allenai/OLMoE-1B-7B-0125-Instruct-GGUF:Q4_K_M",                                  "0.22", "5GB", "open and accessible training data, open-source training code, very fast", "allenai.org", "apache-2.0"],
        ["hf.co/bartowski/AGI-0_Art-0-8B-GGUF:Q4_K_M",                                           "11.9", "6GB", "Exceptional good 8B model, ranking above ChatGPT-3.5", "AGI-0.com and Alibaba", "apache-2.0"],
        ["phi4:14b-q4_K_M",                                                                      "5.24", "10GB", "Very strong, made with synthetic data", "Microsoft", "mit"],
        ["hf.co/mistralai/Magistral-Small-2509-GGUF:Q4_K_M",                                     "5.18", "16GB", "European flagship model, strong multilangual, reasoning", "mistral.ai", "apache-2.0"],
        ["hf.co/bartowski/cognitivecomputations_Dolphin-Mistral-24B-Venice-Edition-GGUF:Q4_K_M", "3.43", "16GB", "Uncensored multilingual european Mistral-24B for role playing", "mistral.ai and dphn.ai", "apache-2.0"],
        ["gemma3:27b-it-q4_K_M",                                                                 "4.81", "20GB", "Strong content safety, multilingual support in over 140 languages", "google.com", "gemma"],
        ["qwen3-vl:30b-a3b-instruct-q4_K_M",                                                     "17.33", "22GB", "Very fast, exceptional good 30B model, ranking above GPT-4-turbo, GPT-4.1-nano, GPT-o1, GPT-4o-mini", "Alibaba", "apache-2.0"]
      ];

      const MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Actions"];
      const RECOMMENDED_MODEL_MAP = new Map(
        RECOMMENDED_MODELS.map(([name, ranking, size, description, provider, license]) => [
          name,
          { ranking, size, description, provider, license }
        ])
      );


      /***
       ***  API functions to access Ollama or OpenAI endpoints (list/load/delete models)
       ***/

      async function fetchJsonOrThrow(url, options = {}) {
        const response = await fetch(url, options);
        if (response.status !== 200) {
          const error = new Error("Model fetch failed");
          error.status = response.status;
          throw error;
        }
        return response.json();
      }

      async function deleteOllamaModel(hoststub, modelName) {
        const response = await fetch(`${hoststub}/api/delete`, {
          method: "DELETE",
          headers: {"Accept": "application/json", "Content-Type": "application/json"},
          body: JSON.stringify({ model: modelName })
        });
        if (response.status !== 200) {
          const error = new Error(`Failed to delete model ${modelName}`);
          error.status = response.status;
          throw error;
        }
      }

      async function downloadOllamaModel(hoststub, modelName) {
        const response = await fetch(`${hoststub}/api/pull`, {
          method: "POST",
          headers: {"Accept": "application/json", "Content-Type": "application/json"},
          body: JSON.stringify({ model: modelName, stream: false })
        });
        if (response.status !== 200) {
          const error = new Error(`Failed to download model ${modelName}`);
          error.status = response.status;
          throw error;
        }
        const payload = await response.json().catch(() => null);
        if (payload && payload.error) {
          const error = new Error(payload.error);
          error.status = response.status;
          error.payload = payload;
          throw error;
        }
        return payload;
      }

      async function requestModelsForService(service, hoststub) {
        return service === "OLLAMA" ? fetchJsonOrThrow(`${hoststub}/api/tags`) : fetchJsonOrThrow(`${hoststub}/v1/models`);
      }

      function handleModelLoadError(service, error) {
        console.error("Error fetching models:", error);
        const status = error && typeof error.status === "number" ? error.status : null;
        if (status) {
          const apikeyEl = document.getElementById("apikey");
          apiKeyValue = apikeyEl ? apikeyEl.value.trim() : "";
          if (service !== "OLLAMA" && service !== "LMSTUDIO" && !apiKeyValue) {
            alert("an api_key is required for this service");
          } else {
            alert(`Failed to load models. HTTP status: ${status}`);
          }
        } else {
          alert("Failed to load models. Check the hoststub and console for errors.");
        }
      }

      async function loadModelList(fromPreset = false) {
        availableModels = [];
        const service = document.getElementById("service").value;
        const hoststub = document.getElementById("hoststub").value;

        try {
          const responsej = await requestModelsForService(service, hoststub);
          renderAvailableModels(service, responsej);
          if (service === "OLLAMA") {
            renderRecommendedModels(hoststub);
          } else {
            const loadModelContainer = document.getElementById("loadModelContainer");
            if (!loadModelContainer) return;
            loadModelContainer.innerHTML = "";
            loadModelContainer.style.display = "none";
          }
          persistInferenceSystem();
        } catch (error) {
          handleModelLoadError(service, error);
          if (fromPreset) {
            console.warn("Auto-load of model list failed for preset inference system.");
          }
        }
      }
      
      
      /***
       ***  Download Activity
       ***/
      
      function updateBeforeUnloadGuard() {
        if (activeDownloadCount > 0) {
          window.addEventListener("beforeunload", beforeUnloadHandler);
        } else {
          window.removeEventListener("beforeunload", beforeUnloadHandler);
        }
      }

      function getDownloadActivityElements() {
        return {
          container: document.getElementById("downloadActivityContainer"),
          list: document.getElementById("downloadActivityList")
        };
      }

      function addDownloadActivity(modelName) {
        const { container, list } = getDownloadActivityElements();
        if (!container || !list) return null;

        const activityId = `download_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
        const wrapper = document.createElement("div");
        wrapper.className = "download-activity";
        wrapper.dataset.activityId = activityId;
        wrapper.style.marginBottom = "8px";
        wrapper.style.padding = "8px";
        wrapper.style.border = "1px solid #ddd";
        wrapper.style.borderRadius = "4px";
        wrapper.style.backgroundColor = "#f8f8f8";

        const title = document.createElement("div");
        title.className = "download-activity-title";
        title.textContent = `Downloading ${modelName}`;
        title.style.fontWeight = "bold";
        title.style.marginBottom = "4px";
        wrapper.appendChild(title);

        const progress = document.createElement("progress");
        progress.max = 100;
        progress.style.width = "100%";
        progress.removeAttribute("value"); // indeterminate
        progress.setAttribute("aria-busy", "true");
        wrapper.appendChild(progress);

        const subtitle = document.createElement("div");
        subtitle.className = "download-activity-subtitle";
        subtitle.textContent = "Download in progress…";
        subtitle.style.fontSize = "0.9em";
        subtitle.style.marginTop = "4px";
        wrapper.appendChild(subtitle);

        list.appendChild(wrapper);
        container.style.display = "block";

        downloadActivities.set(activityId, wrapper);
        activeDownloadCount = downloadActivities.size;
        updateBeforeUnloadGuard();
        return activityId;
      }

      function removeDownloadActivity(activityId) {
        const wrapper = downloadActivities.get(activityId);
        if (wrapper && wrapper.parentNode) {
          wrapper.parentNode.removeChild(wrapper);
        }
        if (downloadActivities.has(activityId)) {
          downloadActivities.delete(activityId);
          activeDownloadCount = downloadActivities.size;
        }
        const { container, list } = getDownloadActivityElements();
        if (container && list && !list.hasChildNodes()) {
          container.style.display = "none";
        }
        updateBeforeUnloadGuard();
      }

      /***
       ***  Rendering Functions
       ***/

      function setHoststub() {
        // this is called when the user changes the service
        const service = document.getElementById("service").value;
        const hoststubInput = document.getElementById("hoststub");
        const apikeyInput = document.getElementById("apikey");

        if (service === "OLLAMA") {
          hoststubInput.value = "http://localhost:11434";
        } else if (service === "LMSTUDIO") {
          hoststubInput.value = "http://localhost:1234";
        } else if (service === "OPENAI") {
          hoststubInput.value = "https://api.openai.com";
        } else if (service === "OPENROUTER") {
          hoststubInput.value = "https://openrouter.ai/api";
        } else {
          hoststubInput.value = "";
        }
        
        if (!apikeyInput) return;

        if (service === "OLLAMA" || service === "LMSTUDIO") {
          apikeyInput.disabled = true;
          apikeyInput.value = "";
        } else {
          apikeyInput.disabled = false;
        }
      }

      function applyPresetInference() {
        const serviceSelect = document.getElementById("service");
        const hoststubInput = document.getElementById("hoststub");
        const apikeyInput = document.getElementById("apikey");
        const body = document.body;
        const presetService = (body.getAttribute("data-llm-service") || "").trim();
        const presetHoststub = (body.getAttribute("data-llm-hoststub") || "").trim();
        const presetApikey = (body.getAttribute("data-llm-apikey") || "").trim();
        if (serviceSelect && presetService) {
          serviceSelect.value = presetService;
        }
        setHoststub();
        if (hoststubInput && presetHoststub) {
          hoststubInput.value = presetHoststub;
        }
        if (apikeyInput && presetApikey) {
          apikeyInput.disabled = false;
          apikeyInput.value = presetApikey;
        }
      }

      async function handleModelDelete(modelName, deleteButton) {
        if (!modelName) return;
        if (getProductionModelNames().has(modelName)) {
          alert(`Model "${modelName}" is currently used in the production table and cannot be deleted.`);
          updateAvailableModelButtons();
          return;
        }
        const hoststubInput = document.getElementById("hoststub");
        const hoststub = hoststubInput ? hoststubInput.value.trim() : "";
        if (!hoststub) {
          alert("A hoststub is required to delete models.");
          return;
        }

        if (deleteButton) {
          deleteButton.disabled = true;
          deleteButton.dataset.deleting = "true";
        }

        try {
          await deleteOllamaModel(hoststub, modelName);
        } catch (error) {
          const status = error && typeof error.status === "number" ? error.status : null;
          const message = status
            ? `Failed to delete model ${modelName}. HTTP status: ${status}`
            : `Error while deleting model ${modelName}. Check the console for details.`;
          alert(message);
          console.error("Model deletion failed:", error);
          return;
        } finally {
          if (deleteButton) {
            deleteButton.disabled = false;
            delete deleteButton.dataset.deleting;
          }
        }

        try {
          await loadModelList();
        } catch (refreshError) {
          console.error("Failed to refresh models after deletion:", refreshError);
        }
      }

      function renderAvailableModels(service, payload) {
        const container = document.getElementById("availableModelsContainer");
        if (!container) return;

        const models = service === "OLLAMA" ? (payload.models || []) : (payload.data || []);
        const getId = service === "OLLAMA" ? m => m.model : m => m.id;
        const rows = [];

        models.forEach(m => {
          const id = getId(m);
          if (!id) return;
          availableModels.push(id);
          const info = getRecommendedModelInfo(id) || {};
          rows.push({
            model: id,
            ranking: info.ranking || "",
            size: info.size || "",
            description: info.description || "",
            service: info.service || "",
            license: info.license || "",
            renderActions: () => createAvailableModelActionButtons(service, id)
          });
        });

        renderModelTable(container, "Available Models", rows);
        updateAvailableModelButtons();
      }

      function renderRecommendedModels(hoststub) {
        const loadModelContainer = document.getElementById("loadModelContainer");
        if (!loadModelContainer) return;

        const downloadableModels = RECOMMENDED_MODELS.filter(m => m && !availableModels.includes(m[0]));
        const rows = downloadableModels.map(m => {
          const info = getRecommendedModelInfo(m[0]) || {};
          return {
            model: m[0],
            ranking: info.ranking || "",
            size: info.size || "",
            description: info.description || "",
            service: info.service || "",
            license: info.license || "",
            renderActions: () => createDownloadButton(hoststub, m[0])
          };
        });

        renderModelTable(loadModelContainer, "Recommended Models", rows);
      }
      
      function getRecommendedModelInfo(modelName) {
        return RECOMMENDED_MODEL_MAP.get(modelName) || null;
      }

      function renderModelTable(container, title, rows) {
        if (!container) return;
        container.innerHTML = `<legend>${title}</legend>`;
        if (!rows || !rows.length) {
          container.style.display = "none";
          return;
        }

        const table = document.createElement("table");
        table.className = "table table-striped";

        const thead = document.createElement("thead");
        thead.className = "thead-dark";
        const headerRow = document.createElement("tr");
        MODEL_TABLE_HEADERS.forEach(h => {
          const th = document.createElement("th");
          th.textContent = h;
          headerRow.appendChild(th);
        });
        thead.appendChild(headerRow);
        table.appendChild(thead);

        const tbody = document.createElement("tbody");
        rows.forEach(row => {
          const tr = document.createElement("tr");
          const columnValues = [
            row.model || "",
            row.ranking || "",
            row.size || "",
            row.description || "",
            row.provider || "",
            row.license || ""
          ];
          columnValues.forEach(value => {
            const td = document.createElement("td");
            td.className = "narrow";
            td.textContent = value;
            tr.appendChild(td);
          });

          const actionsTd = document.createElement("td");
          actionsTd.style.display = "flex";
          actionsTd.style.alignItems = "center";
          actionsTd.style.gap = "6px";
          if (typeof row.renderActions === "function") {
            const actionContent = row.renderActions();
            if (Array.isArray(actionContent)) {
              actionContent.forEach(node => node && actionsTd.appendChild(node));
            } else if (actionContent instanceof Node) {
              actionsTd.appendChild(actionContent);
            }
          }
          tr.appendChild(actionsTd);
          tbody.appendChild(tr);
        });

        table.appendChild(tbody);
        container.appendChild(table);
        container.style.display = "block";
      }

      function getProductionModelNames() {
        const tbody = getProductionTableBody();
        if (!tbody) return new Set();
        const modelNames = new Set();
        Array.from(tbody.querySelectorAll("tr")).forEach(row => {
          if (!row.cells || row.cells.length <= PRODUCTION_MODEL_MODEL_COLUMN_INDEX) {
            return;
          }
          const cell = row.cells[PRODUCTION_MODEL_MODEL_COLUMN_INDEX];
          if (!cell) return;
          const modelName = cell.textContent.trim();
          if (modelName) {
            modelNames.add(modelName);
          }
        });
        return modelNames;
      }

      function updateAvailableModelButtons() {
        const productionModels = getProductionModelNames();
        document.querySelectorAll('button[data-action="delete-model"]').forEach(button => {
          const modelId = button.dataset.modelId;
          if (!modelId || button.dataset.deleting === "true") return;
          const shouldDisable = productionModels.has(modelId);
          button.disabled = shouldDisable;
          button.title = shouldDisable
            ? "Model is assigned as a production model and cannot be deleted."
            : "";
        });
        document.querySelectorAll('button[data-action="deploy-model"]').forEach(button => {
          const modelId = button.dataset.modelId;
          if (!modelId) return;
          const shouldDisable = productionModels.has(modelId);
          button.disabled = shouldDisable;
          button.title = shouldDisable
            ? "Model is already listed in the production table."
            : "";
        });
      }

      function createAvailableModelActionButtons(service, modelId) {
        return [createSelectButton(modelId), createDeleteButton(service, modelId)];
      }

      function createSelectButton(modelId) {
        const selectBtn = document.createElement("button");
        selectBtn.type = "button";
        selectBtn.className = "btn btn-info btn-sm";
        selectBtn.textContent = "Deploy";
        selectBtn.dataset.action = "deploy-model";
        selectBtn.dataset.modelId = modelId;
        styleActionButton(selectBtn);
        selectBtn.addEventListener("click", () => handleModelSelect(modelId));
        return selectBtn;
      }

      function createDeleteButton(service, modelId) {
        const deleteBtn = document.createElement("button");
        deleteBtn.type = "button";
        deleteBtn.className = "btn btn-danger btn-sm";
        deleteBtn.textContent = "Delete";
        styleActionButton(deleteBtn);
        if (service === "OLLAMA") {
          deleteBtn.dataset.action = "delete-model";
          deleteBtn.dataset.modelId = modelId;
          deleteBtn.addEventListener("click", () => {
            const confirmed = window.confirm(`Do you really want to delete model "${modelId}"?`);
            if (!confirmed) {
              return;
            }
            handleModelDelete(modelId, deleteBtn);
          });
        } else {
          deleteBtn.disabled = true;
          deleteBtn.title = "Model management is only supported for Ollama.";
        }
        return deleteBtn;
      }

      function styleActionButton(button) {
        if (!button) return button;
        button.style.padding = "2px 8px";
        button.style.lineHeight = "1.2";
        button.style.display = "inline-flex";
        button.style.alignItems = "center";
        return button;
      }

      function handleModelSelect(modelName) {
        if (!modelName) return;
        upsertProductionModel(modelName);
      }

      function upsertProductionModel(modelName) {
        const tbody = getProductionTableBody();
        if (!tbody) return;
        const hadRowsBeforeInsert = tbody.querySelectorAll("tr").length > 0;

        const serviceField = document.getElementById("service");
        const hoststubField = document.getElementById("hoststub");
        const apikeyField = document.getElementById("apikey");
        const maxTokenField = document.getElementById("maxtoken");

        const service = serviceField ? serviceField.value : "";
        const hoststub = hoststubField ? hoststubField.value.trim() : "";
        const apikey = apikeyField ? apikeyField.value.trim() : "";
        const maxToken = maxTokenField ? maxTokenField.value : "";

        let targetRow = Array.from(tbody.querySelectorAll("tr")).find(row => {
          const modelCell = row.cells && row.cells[PRODUCTION_MODEL_MODEL_COLUMN_INDEX];
          return modelCell && modelCell.textContent.trim() === modelName;
        });
        const isNewRow = !targetRow;

        if (!targetRow) {
          targetRow = document.createElement("tr");
          targetRow.className = "TableCellLight";
          for (let i = 0; i < PRODUCTION_MODEL_TOTAL_COLUMNS; i += 1) {
            targetRow.appendChild(document.createElement("td"));
          }
          tbody.appendChild(targetRow);
        } else if (targetRow.cells.length < PRODUCTION_MODEL_TOTAL_COLUMNS) {
          const missing = PRODUCTION_MODEL_TOTAL_COLUMNS - targetRow.cells.length;
          for (let i = 0; i < missing; i += 1) {
            targetRow.appendChild(document.createElement("td"));
          }
        }

        const cells = targetRow.cells;
        const values = [service, modelName, hoststub, apikey, maxToken];
        values.forEach((value, index) => {
          if (cells[index]) {
            cells[index].textContent = value || "";
          }
        });

        ensureProductionRowUsageCells(targetRow, !hadRowsBeforeInsert);
        ensureProductionRowActionButton(targetRow);
        persistProductionModels();
        if (isNewRow) {
          triggerToolingCapabilityVerification(targetRow, { hoststub, modelName, apikey });
          triggerVisionCapabilityVerification(targetRow, { hoststub, modelName, apikey });
        }
      }

      function getProductionTableBody() {
        const table = document.getElementById("productionModelsTable");
        return table ? table.querySelector("tbody") : null;
      }

      function normalizeProductionModelRows() {
        const tbody = getProductionTableBody();
        if (!tbody) return;
        Array.from(tbody.querySelectorAll("tr")).forEach(row => {
          const missingCells = PRODUCTION_MODEL_TOTAL_COLUMNS - row.cells.length;
          for (let i = 0; i < missingCells; i += 1) {
            row.appendChild(document.createElement("td"));
          }
          ensureProductionRowUsageCells(row, false);
          ensureProductionRowActionButton(row);
        });
        updateAvailableModelButtons();
      }

      function persistInferenceSystem() {
        const hoststubInput = document.getElementById("hoststub");
        const apikeyInput = document.getElementById("apikey");
        const serviceSelect = document.getElementById("service");
        const inference_system = {
          service: serviceSelect ? serviceSelect.value : "",
          hoststub: hoststubInput ? hoststubInput.value : "",
          api_key: apikeyInput ? apikeyInput.value : ""
        };
        fetch(PRODUCTION_MODEL_SUBMIT_URL, {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ inference_system })
        }).catch(err => {
          console.error("Failed to persist inference system", err);
        });
      }

      function ensureProductionRowUsageCells(row, defaultChecked) {
        if (!row) return;
        // ensure existence of checkboxes
        for (let col = PRODUCTION_MODEL_USAGE_COLUMN_START; col <= PRODUCTION_MODEL_FEATURE_COLUMN_END; col += 1) {
          const cell = row.cells[col];
          if (!cell) continue;
          let checkbox = cell.querySelector('input[type="checkbox"]');
          if (!checkbox) {
            checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            cell.textContent = "";
            cell.appendChild(checkbox);
            checkbox.checked = !!defaultChecked && col <= PRODUCTION_MODEL_USAGE_COLUMN_END;
            if (col >= PRODUCTION_MODEL_FEATURE_COLUMN_START) {
                // disable the checkbox
                checkbox.disabled = true
            }
          }
          initializeUsageCheckbox(checkbox, col);
        }
        if (defaultChecked) {
            // enforce feature exclusivity for row
            if (!row) return;
            for (let col = PRODUCTION_MODEL_USAGE_COLUMN_START; col <= PRODUCTION_MODEL_USAGE_COLUMN_END; col += 1) {
              const cell = row.cells[col];
              if (!cell) continue;
              const checkbox = cell.querySelector('input[type="checkbox"]');
              if (!checkbox || !checkbox.checked) continue;
              handleUsageCheckboxToggle(col, checkbox);
            }
        }
        updateUndeployButtonState(row);
      }

      function initializeUsageCheckbox(checkbox, col) {
        if (!checkbox) return;
        checkbox.dataset.featureColumn = String(col);
        if (checkbox.dataset.listenerAttached === "true") {
          return;
        }
        checkbox.addEventListener("change", event => {
          handleUsageCheckboxToggle(col, event.currentTarget);
        });
        checkbox.dataset.listenerAttached = "true";
      }

      function handleUsageCheckboxToggle(columnIndex, checkbox) {
        if (!checkbox) return;
        const tbody = getProductionTableBody();
        if (!tbody) return;
        const currentRow = checkbox.closest("tr");

        if (checkbox.checked) {
          Array.from(tbody.querySelectorAll("tr")).forEach(row => {
            const cell = row.cells[columnIndex];
            if (!cell) return;
            const otherCheckbox = cell.querySelector('input[type="checkbox"]');
            if (!otherCheckbox || otherCheckbox === checkbox) return;
            if (otherCheckbox.checked) {
              otherCheckbox.checked = false;
              updateUndeployButtonState(row);
            }
          });
        } else {
          ensureFeatureAssignedToAnotherModel(columnIndex, currentRow);
        }

        if (currentRow) {
          updateUndeployButtonState(currentRow);
        }
        persistProductionModels();
      }

      function ensureProductionRowActionButton(row) {
        if (!row || row.cells.length < PRODUCTION_MODEL_TOTAL_COLUMNS) {
          return;
        }
        const actionCell = row.cells[PRODUCTION_MODEL_ACTION_COLUMN_INDEX];
        if (!actionCell) return;
        actionCell.textContent = "";
        const undeployBtn = document.createElement("button");
        undeployBtn.type = "button";
        undeployBtn.className = "btn btn-warning btn-sm";
        undeployBtn.textContent = "Undeploy";
        undeployBtn.dataset.action = "undeploy";
        styleActionButton(undeployBtn);
        undeployBtn.addEventListener("click", () => {
          // reassign any active features before removing this row
          for (let col = PRODUCTION_MODEL_USAGE_COLUMN_START; col <= PRODUCTION_MODEL_USAGE_COLUMN_END; col += 1) {
            const cell = row.cells[col];
            if (!cell) continue;
            const checkbox = cell.querySelector('input[type="checkbox"]');
            if (checkbox && checkbox.checked) {
              ensureFeatureAssignedToAnotherModel(col, row);
            }
          }
          row.remove();
          persistProductionModels();
        });
        actionCell.appendChild(undeployBtn);
        updateUndeployButtonState(row);
      }

      function updateUndeployButtonState(row) {
        if (!row) return;
        const button = row.querySelector('button[data-action="undeploy"]');
        if (!button) return;
        button.disabled = false;
        button.title = "Remove this model (features will be reassigned if possible).";
      }

      function ensureFeatureAssignedToAnotherModel(columnIndex, sourceRow) {
        const tbody = getProductionTableBody();
        if (!tbody) return;
        const rows = Array.from(tbody.querySelectorAll("tr"));
        if (rows.length <= 1) return; // nothing to reassign to
        // If any other row already has the feature, keep it.
        const othersHave = rows.some(r => {
          if (r === sourceRow) return false;
          const cell = r.cells[columnIndex];
          const cb = cell ? cell.querySelector('input[type="checkbox"]') : null;
          return cb && cb.checked;
        });
        if (othersHave) return;
        // pick the first other row and assign
        const target = rows.find(r => r !== sourceRow);
        if (!target) return;
        const targetCell = target.cells[columnIndex];
        const targetCb = targetCell ? targetCell.querySelector('input[type="checkbox"]') : null;
        if (targetCb) {
          targetCb.checked = true;
          updateUndeployButtonState(target);
        }
      }

      function persistProductionModels() {
        // read out table
        const tbody = getProductionTableBody();
        if (!tbody) return [];
        const production_models_table = [];
        Array.from(tbody.querySelectorAll("tr")).forEach(row => {
          if (!row.cells || row.cells.length < PRODUCTION_MODEL_TOTAL_COLUMNS) {
            return;
          }
          const rowData = {};
          PRODUCTION_MODEL_COLUMN_NAMES.forEach((columnName, index) => {
            if (index >= PRODUCTION_MODEL_USAGE_COLUMN_START && index <= PRODUCTION_MODEL_FEATURE_COLUMN_END) {
              const checkbox = row.cells[index] ? row.cells[index].querySelector('input[type="checkbox"]') : null;
              rowData[columnName] = checkbox ? checkbox.checked : false;
            } else {
              rowData[columnName] = row.cells[index] ? row.cells[index].textContent.trim() : "";
            }
          });
          const hasContent = rowData.service || rowData.model || rowData["hoststub"];
          if (hasContent) {
            production_models_table.push(rowData);
          }
        });
        
        // push to server
        const hoststubInput = document.getElementById("hoststub");
        const apikeyInput = document.getElementById("apikey");
        const serviceSelect = document.getElementById("service");
        const inference_system = {
          service: serviceSelect ? serviceSelect.value : "",
          hoststub: hoststubInput ? hoststubInput.value : "",
          api_key: apikeyInput ? apikeyInput.value : ""
        };

        fetch(PRODUCTION_MODEL_SUBMIT_URL, {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ production_models: production_models_table, inference_system })
        }).catch(err => {
          console.error("Failed to persist production models", err);
        });
        updateAvailableModelButtons();
      }

      /**
       * Tooling capability check
       */
      function triggerToolingCapabilityVerification(row, { hoststub, modelName, apikey }) {
        if (!row) return;
        const normalizedHoststub = (hoststub || "").trim();
        if (!normalizedHoststub || !modelName) {
          return;
        }
        if (row.dataset.toolingTestInFlight === "true") {
          return;
        }
        row.dataset.toolingTestInFlight = "true";
        runToolingCapabilityTest(normalizedHoststub, modelName, apikey)
          .then(success => {
            const rowStillMounted = !!(document && document.body && document.body.contains(row));
            if (!success || !rowStillMounted) {
              return;
            }
            setToolingFlagForRow(row, true);
            persistProductionModels();
          })
          .catch(error => {
            console.warn(`Tooling capability check failed for model "${modelName}".`, error);
          })
          .finally(() => {
            delete row.dataset.toolingTestInFlight;
          });
      }

      async function runToolingCapabilityTest(hoststub, modelName, apikey) {
        const endpointBase = hoststub.replace(/\/+$/, "");
        if (!endpointBase) {
          return false;
        }
        const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
        const headers = { "Content-Type": "application/json" };
        if (apikey) {
          headers.Authorization = `Bearer ${apikey}`;
        }
        const payload = buildToolingTestPayload(modelName);
        const response = await fetch(targetUrl, {
          method: "POST",
          headers,
          body: JSON.stringify(payload)
        });
        if (!response.ok) {
          throw new Error(`HTTP status ${response.status}`);
        }
        const result = await response.json();
        return toolingResponseIncludesExpectedToolCall(result);
      }

      function buildToolingTestPayload(modelName) {
        return {
          model: modelName,
          temperature: 0.1,
          max_tokens: 1024,
          messages: [
            { role: "system", content: TEST_STRINGS.toolingSystemMessage },
            { role: "user", content: TEST_STRINGS.toolingUserMessage }
          ],
          tools: [{
            type: "function",
            function: {
              name: TOOLING_EXPECTED_FUNCTION_NAME,
              description: "With this tool you can switch on the light",
              parameters: {
                type: "object",
                properties: {
                  switch: {
                    type: "boolean",
                    description: "true for on, false for off"
                  }
                },
                required: ["switch"],
                additionalProperties: false
              },
              strict: true
            }
          }],
          stream: false
        };
      }

      function toolingResponseIncludesExpectedToolCall(response) {
        if (!response || !Array.isArray(response.choices)) {
          return false;
        }
        return response.choices.some(choice => {
          const message = choice ? choice.message : null;
          if (!message) {
            return false;
          }
          const toolCalls = getToolCallsFromMessage(message);
          if (!toolCalls.length) {
            return false;
          }
          return toolCalls.some(call => {
            const fn = call && call.function;
            return fn && fn.name === TOOLING_EXPECTED_FUNCTION_NAME;
          });
        });
      }

      function getToolCallsFromMessage(message) {
        if (!message) return [];
        const candidates = message.tool_calls || message.tool_call || null;
        if (!candidates) return [];
        if (Array.isArray(candidates)) {
          return candidates;
        }
        if (Array.isArray(candidates.data)) {
          return candidates.data;
        }
        return [candidates];
      }

      function setToolingFlagForRow(row, enabled) {
        if (!row || row.cells.length <= PRODUCTION_MODEL_TOOLING_COLUMN_INDEX) {
          return;
        }
        const cell = row.cells[PRODUCTION_MODEL_TOOLING_COLUMN_INDEX];
        if (!cell) return;
        const checkbox = cell.querySelector('input[type="checkbox"]');
        if (!checkbox) return;
        checkbox.checked = !!enabled;
      }

      function triggerVisionCapabilityVerification(row, { hoststub, modelName, apikey }) {
        if (!row) return;
        const normalizedHoststub = (hoststub || "").trim();
        if (!normalizedHoststub || !modelName) {
          return;
        }
        if (row.dataset.visionTestInFlight === "true") {
          return;
        }
        row.dataset.visionTestInFlight = "true";
        runVisionCapabilityTest(normalizedHoststub, modelName, apikey)
          .then(success => {
            const rowStillMounted = !!(document && document.body && document.body.contains(row));
            if (!success || !rowStillMounted) {
              return;
            }
            setVisionFlagForRow(row, true);
            persistProductionModels();
          })
          .catch(error => {
            console.warn(`Vision capability check failed for model "${modelName}".`, error);
          })
          .finally(() => {
            delete row.dataset.visionTestInFlight;
          });
      }

      async function runVisionCapabilityTest(hoststub, modelName, apikey) {
        const endpointBase = hoststub.replace(/\/+$/, "");
        if (!endpointBase) {
          return false;
        }
        const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
        const headers = { "Content-Type": "application/json" };
        if (apikey) {
          headers.Authorization = `Bearer ${apikey}`;
        }
        const base64Image = await loadVisionTestImageBase64();
        const payload = buildVisionTestPayload(modelName, base64Image);
        const response = await fetch(targetUrl, {
          method: "POST",
          headers,
          body: JSON.stringify(payload)
        });
        if (!response.ok) {
          throw new Error(`HTTP status ${response.status}`);
        }
        const result = await response.json();
        return visionResponseContainsExpectedAnswer(result);
      }

      function buildVisionTestPayload(modelName, base64Image) {
        return {
          model: modelName,
          temperature: 0.1,
          max_tokens: 512,
          messages: [
            { role: "system", content: TEST_STRINGS.visionSystemMessage },
            {
              role: "user",
              content: [
                { type: "text", text: TEST_STRINGS.visionUserMessage },
                {
                  type: "image_url",
                  image_url: {
                    url: `data:image/png;base64,${base64Image}`
                  }
                }
              ]
            }
          ]
        };
      }

      function visionResponseContainsExpectedAnswer(response) {
        if (!response || !Array.isArray(response.choices)) {
          return false;
        }
        return response.choices.some(choice => {
          const message = choice ? choice.message : null;
          const normalizedText = normalizeMessageText(message);
          if (!normalizedText) {
            return false;
          }
          return normalizedText.indexOf(TEST_STRINGS.visionExpectedText) !== -1;
        });
      }

      function normalizeMessageText(message) {
        if (!message) return "";
        const { content } = message;
        if (typeof content === "string") {
          return content.trim();
        }
        if (Array.isArray(content)) {
          return content.map(extractTextFromContent).filter(Boolean).join(" ").trim();
        }
        if (content && typeof content.text === "string") {
          return content.text.trim();
        }
        return "";
      }

      function extractTextFromContent(part) {
        if (!part) return "";
        if (typeof part === "string") return part;
        if (typeof part.text === "string") return part.text;
        if (typeof part.content === "string") return part.content;
        return "";
      }

      async function loadVisionTestImageBase64() {
        if (cachedVisionTestImageBase64) {
          return cachedVisionTestImageBase64;
        }
        if (cachedVisionTestImagePromise) {
          return cachedVisionTestImagePromise;
        }
        cachedVisionTestImagePromise = fetch(TEST_STRINGS.visionTestImagePath)
          .then(response => {
            if (!response.ok) {
              throw new Error(`Failed to load test image (${response.status})`);
            }
            return response.blob();
          })
          .then(blob => blobToBase64(blob))
          .then(base64 => {
            cachedVisionTestImageBase64 = base64;
            return base64;
          })
          .catch(error => {
            cachedVisionTestImagePromise = null;
            throw error;
          });
        return cachedVisionTestImagePromise;
      }

      function blobToBase64(blob) {
        return new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.onerror = () => reject(reader.error || new Error("Failed to read blob"));
          reader.onloadend = () => {
            const result = reader.result;
            if (typeof result !== "string") {
              reject(new Error("Unexpected data when reading blob"));
              return;
            }
            const commaIndex = result.indexOf(",");
            resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
          };
          reader.readAsDataURL(blob);
        });
      }

      function setVisionFlagForRow(row, enabled) {
        if (!row || row.cells.length <= PRODUCTION_MODEL_VISION_COLUMN_INDEX) {
          return;
        }
        const cell = row.cells[PRODUCTION_MODEL_VISION_COLUMN_INDEX];
        if (!cell) return;
        const checkbox = cell.querySelector('input[type="checkbox"]');
        if (!checkbox) return;
        checkbox.checked = !!enabled;
      }

      function createDownloadButton(hoststub, modelName) {
        const downloadBtn = document.createElement("button");
        downloadBtn.type = "button";
        downloadBtn.className = "btn btn-primary btn-sm";
        downloadBtn.textContent = "Download";
        styleActionButton(downloadBtn);
        downloadBtn.addEventListener("click", async () => {
          const activityId = addDownloadActivity(modelName);
          downloadBtn.disabled = true;
          try {
            await downloadOllamaModel(hoststub, modelName);
            console.log(`Model ${modelName} is now available on server ${hoststub}.`);
          } catch (err) {
            const status = err && typeof err.status === "number" ? err.status : null;
            const message = status
              ? `Failed to download model ${modelName}. HTTP status: ${status}`
              : `Error pulling model ${modelName} from server ${hoststub}. Check the console for details.`;
            alert(message);
            console.error("Error during model pull request:", err);
          } finally {
            downloadBtn.disabled = false;
            if (activityId) {
              removeDownloadActivity(activityId);
            }
            try {
              await loadModelList();
            } catch (refreshError) {
              console.error("Failed to refresh models after download:", refreshError);
            }
          }
        });
        return downloadBtn;
      }

      document.addEventListener("DOMContentLoaded", () => {
        try {
          normalizeProductionModelRows();
          applyPresetInference();
          // auto-show available models if a preset inference exists
          const body = document.body;
          const presetService = (body.getAttribute("data-llm-service") || "").trim();
          const presetHoststub = (body.getAttribute("data-llm-hoststub") || "").trim();
          if (presetService && presetHoststub) {
            loadModelList(true);
          }
        } catch (e) {
          console.error("Initialization failed", e);
        }
      });

    </script>

    
    <h2>LLM Selection</h2>

    <p>
        Here you can pick models from a LLM model service to select them as production model.
        In the "Production Models Matrix" you can then assign each selected model a function inside YaCy
    </p>
    <p>
        <b>Install your local LLM service!</b> You need either a local <a href="https://ollama.com/">ollama</a> or <a href="https://lmstudio.ai/">LM Studio</a> instance running on your local host or inside the intranet.
    </p>
    <form id="llmForm">
    <fieldset><legend>Service Selection</legend>
      <dl>
        <dt class="TableCellDark">service</dt>
        <dd>
            <select name="service" id="service" class="form-control" onchange="setHoststub()">
            <option value="OLLAMA" selected="selected">Ollama</option>
            <option value="LMSTUDIO">LMStudio</option>
            <option value="OPENAI">OpenAI</option>
            <option value="OPENROUTER">Open Router</option>
            </select>&nbsp; This makes a preset to the Hoststub value
        </dd>

        <dt class="TableCellDark">hoststub</dt>
        <dd><input type="text" name="hoststub" id="hoststub" value="http://localhost:11434" size="30" maxlength="60" class="form-control"/>&nbsp; you can probably leave this to the default value
        </dd>

        <dt class="TableCellDark">api_key</dt>
        <dd><input type="text" name="apikey" id="apikey" value="" disabled=true size="30" maxlength="60" class="form-control"/>&nbsp; (not required for Ollama or LMStudio)
        </dd>
        
        <dt class="TableCellDark">max_tokens</dt>
        <dd>
            <select id="maxtoken" name="maxtoken" class="form-control">
                <option selected="selected">4096</option>
                <option>8192</option>
                <option>16384</option>
                <option>32768</option>
                <option>65536</option>
                <option>131072</option>
                <option>262440</option>
            </select>&nbsp; You must set the Context Length in the LLM service to fit to your selected max_tokens; in Ollama you find a Context Length slider in the settings
        </dd>

        <dt>&nbsp;</dt>
        <dd><input name="llmselection" value="Load Model Name List" class="btn btn-primary" style="width:240px;" onclick="loadModelList()"/>
        </dd>
      </dl>
    </fieldset>
    </form>

    <fieldset id="loadModelContainer" style="display:none"></fieldset>
    <fieldset id="downloadActivityContainer" style="display:none">
      <legend>Model Downloads</legend>
      <div id="downloadActivityList"></div>
    </fieldset>
    <fieldset id="availableModelsContainer" style="display:none"><a name="availableModels"></a></fieldset>

    <fieldset id="productionModelsContainer" style="display: block;"><a name="productionModels"></a><legend>Production Models Matrix</legend>
        <table class="table table-striped" id="productionModelsTable">
            <thead class="thead-dark">
                <tr>
                    <td>service</td>
                    <td>model</td>
                    <td>hoststub</td>
                    <td>api_key</td>
                    <td>max_tokens</td>
                    
                    <td class="narrow">search-answers<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model creates answers for search requests</span></span></td>
                    <td class="narrow">chat<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model is used in the chat interface and as default for the RAG proxy</span></span></td>
                    <td class="narrow">translation<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model can be used to make translations of the web UI</span></span></td>
                    <td class="narrow">classification<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model is used to classify prompts to find out what they demand</span></span></td>
                    <td class="narrow">search-query<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model produces search queries to YaCy search from prompts in RAG or chat</span></span></td>
                    <td class="narrow">qa-pairs<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model can be used to produce query-answer pairs which enhance search from chat prompts</span></span></td>
                    <td class="narrow">tldr-shortener<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model is used to make summaries from web content</span></span></td>
                    
                    <td>tooling</td>
                    <td>vision</td>
                    <td>Actions</td>
                </tr>
            </thead>
            <tbody>
        #{productionmodels}#
            <tr class="TableCell#(dark)#Light::Dark#(/dark)#">
                <td>#[service]#</td>
                <td>#[model]#</td>
                <td>#[hoststub]#</td>
                <td>#[api_key]#</td>
                <td>#[max_tokens]#</td>
                
                <td><input type="checkbox" #(search)#::checked=true#(/search)#></td>
                <td><input type="checkbox" #(chat)#::checked=true#(/chat)#></td>
                <td><input type="checkbox" #(translation)#::checked=true#(/translation)#></td>
                <td><input type="checkbox" #(classification)#::checked=true#(/classification)#></td>
                <td><input type="checkbox" #(query)#::checked=true#(/query)#></td>
                <td><input type="checkbox" #(qapairs)#::checked=true#(/qapairs)#></td>
                <td><input type="checkbox" #(tldr)#::checked=true#(/tldr)#></td>
                
                <td><input type="checkbox" #(tooling)#::checked=true#(/tooling)# disabled=true></td>
                <td><input type="checkbox" #(vision)#::checked=true#(/vision)# disabled=true></td>
                <td></td>
            </tr>
        #{/productionmodels}#
            </tbody>
        </table>
    </fieldset>

    #%env/templates/footer.template%#
  </body>
</html>
